iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Software Development

AI寫得出程式,但它不知道你的店:一家租書店的30天技術選型實錄系列 第 3

Day 3:三個名詞變成程式碼,然後測試說這家店不像真的

  • 分享至 

  • xImage
  •  

昨天從紙卡上抄下三個名詞:書、會員、一次借還。今天它們第一次變成程式碼。

今天要做兩樣東西:一個種子資料產生器,和第一版的櫃檯程式。順序是刻意的,先做資料。

先做資料的原因很現實:這家店是虛構的,沒有真的卡片盒可以翻。接下來二十七天每一次量測都要有資料,而且必須是同一組資料——假如第十二天和第二十天各量到一個數字,底下不是同一家店,兩個數字擺在一起就沒有意義。

建專案

三個專案:產生器本體、它的測試、櫃檯程式。

mkdir highflyer-ironman && cd highflyer-ironman
dotnet new sln -n Ironman
dotnet new classlib -n Ironman.SeedData       -o src/Ironman.SeedData
dotnet new xunit    -n Ironman.SeedData.Tests -o tests/Ironman.SeedData.Tests
dotnet new console  -n Ironman.Desk           -o src/Ironman.Desk
dotnet sln add src/Ironman.SeedData tests/Ironman.SeedData.Tests src/Ironman.Desk
dotnet add tests/Ironman.SeedData.Tests reference src/Ironman.SeedData
dotnet add src/Ironman.Desk              reference src/Ironman.SeedData

共用設定放在方案根目錄的Directory.Build.props,之後每加一個專案都不用再抄一次:

<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>
</Project>

TreatWarningsAsErrors是刻意的:三十天會累積不少程式碼,警告放著不管就會變成背景雜訊,之後真正要緊的那一個會被蓋掉。

再加一個global.json把SDK限制在 .NET 10,免得讀者的機器上裝了別的主要版本,跑出來的東西和文章對不上:

{
  "sdk": {
    "version": "10.0.100",
    "rollForward": "latestFeature"
  }
}

rollForward設成latestFeature10.0.3xx這類同主版本的更新也能跑,不用每次升級都改檔案。反過來說它也不是鎖定同一版:筆者機器上實際選到的是10.0.301。要完全一致得改成disable,這裡不需要做到那個程度。

三個名詞落地

src/Ironman.SeedData/Models.cs。一行一個名詞,註解寫著它對應紙卡上的哪個位置:

namespace Ironman.SeedData;

/// <summary>書目資料。對應紙卡:借閱卡上緣的書名、作者、ISBN。</summary>
public sealed record Book(string Isbn, string Title, string Author);

/// <summary>會員。對應紙卡:會員卡上的會員編號、姓名、電話。</summary>
public sealed record Member(string MemberId, string Name, string Phone);

/// <summary>一次借出與歸還。對應紙卡:借閱卡上的每一行。</summary>
public sealed record Loan(string MemberId, string Isbn, DateOnly LoanDate, DateOnly? ReturnDate)
{
    public bool IsReturned => ReturnDate is not null;
}

/// <summary>一組種子資料。三個集合之間的參照(MemberId、Isbn)保證存在。</summary>
public sealed record SeedDataSet(
    Scale Scale,
    int Seed,
    IReadOnlyList<Book> Books,
    IReadOnlyList<Member> Members,
    IReadOnlyList<Loan> Loans);

有三個決定要交代。

record不是為了時髦。這三個東西目前只有資料、沒有行為;而且record比較相等是逐欄位比,等一下那條「同一個seed產生完全相同的資料」的測試才能直接拿兩組資料比——一般的class預設比的是「是不是同一個物件」,兩筆欄位一模一樣的Book會被判定成不同。

ReturnDateDateOnly?,不是一個IsReturned布林欄位。紙卡上沒有打勾欄,有的是「歸還日期」那一格:填了就是還了,空著就是還在外面。null就是那一格空白。IsReturned只是讀那一格的方便寫法,不是另一份資料——兩份資料就會有對不起來的一天。

Loan指向Isbn,不是指向某一本實體書。現在每種書只有一本,這樣夠用。同一本書買了兩本會出什麼事,第六天會真的發生,那時再改。

規模:三個數字,三十天都用它

public enum Scale { S, M, L }

public readonly record struct ScaleProfile(int Books, int Members, int Loans)
{
    public static ScaleProfile For(Scale scale) => scale switch
    {
        Scale.S => new(Books:    100, Members:   40, Loans:       500),
        Scale.M => new(Books:  2_000, Members:  600, Loans:    50_000),
        Scale.L => new(Books: 20_000, Members: 5_000, Loans: 1_000_000),
        _ => throw new ArgumentOutOfRangeException(nameof(scale), scale, "未定義的規模"),
    };
}

S是開店第一年、M是第三年、L是第十年。這三組數字從今天釘死,往後不再改——改了,前面所有量測就得重跑。

ISBN:看起來像,而且驗得出來

書名和人名由一個小詞庫組合出來,不對應任何真實的人或書。ISBN比較講究一點,因為它有規則可循:

/// <summary>ISBN-13 檢查碼:奇數位權重 1、偶數位權重 3,總和補到 10 的倍數。</summary>
public static int CheckDigit(ReadOnlySpan<int> first12)
{
    var sum = 0;
    for (var i = 0; i < 12; i++)
    {
        sum += first12[i] * (i % 2 == 0 ? 1 : 3);
    }

    return (10 - sum % 10) % 10;
}

為什麼不隨便產十三位數字就好?因為之後幾篇會拿ISBN當鍵去查、去比對、去當關聯的依據。假資料如果連自己的規則都不守,出問題的時候分不清是程式錯還是資料本來就爛。

這只做到「978開頭、檢查碼算得過」,不代表這組號碼沒有被真的配發給某本書——那要查ISBN的官方配發紀錄才知道,這裡不需要。

固定seed

關鍵只有一行:new Random(seed)

public const int DefaultSeed = 20260905;

public static SeedDataSet Generate(Scale scale, int seed = DefaultSeed)
{
    var profile = ScaleProfile.For(scale);
    var random = new Random(seed);

    var books = GenerateBooks(random, profile.Books);
    var members = GenerateMembers(random, profile.Members);
    var (periodStart, periodEnd) = PeriodFor(scale);
    var loans = GenerateLoans(random, books, members, profile.Loans, periodStart, periodEnd);

    return new SeedDataSet(scale, seed, books, members, loans);
}

在同一個 .NET 主要版本、同一份詞庫、同一個取數順序底下,同一個種子會得到同一組資料。官方保證的就是這個範圍——Random的演算法不保證跨主要版本不變,所以global.json那一段不只是潔癖。沒指定種子的new Random()則沒有這個性質:拿不到一個可以重現的起點。

所以整個產生器只有這一個亂數實例,三個集合按固定順序取數,也不用平行處理——順序一亂,結果就變了。讀者用專案指定的 .NET 10 跑Generate(Scale.S),拿到的會是同一位會員借走同一本書、同一天借的。文章裡的數字可以自己對。

把不能破的規則寫成測試

不能破的規則先列十條,全部寫成測試:

同一個seed產生完全相同的資料
不同seed產生不同的資料
每個規模的數量符合定義
ISBN不重複且檢查碼有效
會員編號不重複
每筆借閱都指向存在的書與會員
歸還日不早於借出日
同一本書的借閱期間不重疊
借閱日期都落在該規模的期間內
有一部分借閱尚未歸還

第一條是這個產生器存在的理由,寫出來只有三行:

[Fact]
public void 同一個seed產生完全相同的資料()
{
    var a = SeedDataGenerator.Generate(Scale.S);
    var b = SeedDataGenerator.Generate(Scale.S);

    Assert.Equal(a.Books, b.Books);
    Assert.Equal(a.Members, b.Members);
    Assert.Equal(a.Loans, b.Loans);
}

Assert.Equal直接拿兩組資料比,靠的就是前面選record那個決定。

最後一條看起來最無聊,它只要求至少有一筆還沒還:

[Fact]
public void 有一部分借閱尚未歸還()
{
    var data = SeedDataGenerator.Generate(Scale.S);

    Assert.Contains(data.Loans, l => !l.IsReturned);
}

這十條規則跑出十六個測試案例(有一條用[Theory]帶了三個規模,另外還有ISBN檢查碼自己的測試)。就這一條紅了。

測試說:這不是一家還在營業的店

[xUnit.net 00:00:00.07]     SeedDataGeneratorTests.有一部分借閱尚未歸還 [FAIL]
  Error Message:
   Assert.Contains() Failure: Filter not matched in collection

Failed!  - Failed:     1, Passed:    15, Skipped:     0, Total:    16

五百筆借閱裡,未歸還是零。

筆者把資料印出來翻才看懂:借閱全擠在前面幾個月,最後一筆借出停在九月二十一日;十月、十一月、十二月,整家店架上乾乾淨淨,沒有一本書在客人手上。

一家還在營業的租書店不會長這樣。跨年那天,櫃檯的卡片盒裡一定有一疊卡還插著——那就是昨天說的、老闆晚上要數的那一疊。連續三個月未歸還都是零,現實裡只有一種解釋:這家店九月就收攤了。

產生器沒有寫錯任何一行。編譯過了,其他十五個案例也都過了。它只是造出一家不可能存在的店。

問題出在一行想當然耳的設定。筆者原本寫「一本書還回來之後,等0到20天再被借走」——書還回來、在架上躺幾天、又被借走,聽起來很合理。但五百筆借閱配一百本書,平均每本五筆;等待平均十天、借閱平均十五天多,一筆佔二十幾天,照平均五筆估算,排完連九個月都不到。

等待天數不能寫死,要跟著期間長度走:

// 兩次借出之間的等待天數上限,依「每本書要在期間內排進幾筆」回推,
// 讓借閱平均分布在整個期間,而不是擠在期間開頭。
var periodDays = periodEnd.DayNumber - periodStart.DayNumber;
var loansPerBook = (double)count / books.Count;
var slotDays = periodDays * 0.85 / loansPerBook;   // 每筆借閱平均佔用的天數(留 15% 餘裕)
var maxWaitDays = Math.Max(1, (int)(2 * (slotDays - AverageLoanDays)));

兩個數字要分開講。乘2是因為random.Next(0, max + 1)的平均值大約是上限的一半,要讓平均等到slotDays - AverageLoanDays那麼久,上限就得放到兩倍。0.85不是推導出來的,是試出來的餘裕,留15%給期間前後的邊界,免得最後一筆衝出營運期間。

S規模因此變成0到93天。書會在架上多躺一陣子,年底才有書還在外面,那條測試轉綠——十六個案例全過。這只代表目前列出的規則沒有被破,不代表這家店已經像真的;至少它不再是年底一筆未歸還都沒有的店。

這一條測的不是「這段程式有沒有算錯」,是「造出來的資料像不像真實世界會發生的事」。放著不管的代價不是當機:第三十天之前還有二十七天的量測——查詢要多久、記憶體吃多少、逾期罰金怎麼算——全部都會跑在一家沒有人借書的空店上。未歸還永遠是零,「查會員未歸還」永遠回空的,量出來的數字看起來很正常,而且不會有任何錯誤訊息。

這個產生器交給AI寫,它寫得比筆者快,很可能一次就把程式寫到能跑、測試也能過。它不會問的是:這家店年底有書在外面嗎。

櫃檯上的第一版

有了資料,才輪到程式。老闆買了一台電腦放櫃檯,早晚班兩位店員輪流用,第一版是一支命令列程式。

借出規則住在RentalDesk裡:

public Loan Lend(string memberId, string isbn, DateOnly today)
{
    var member = FindMember(memberId)
        ?? throw new RentalException($"找不到會員 {memberId},先確認會員卡上的編號。");
    var book = FindBook(isbn)
        ?? throw new RentalException($"找不到 ISBN {isbn} 的書,這本書沒有建檔。");

    var outstanding = OutstandingLoanOf(isbn);
    if (outstanding is not null)
    {
        var holder = FindMember(outstanding.MemberId)?.Name ?? outstanding.MemberId;
        throw new RentalException($"《{book.Title}》已經在 {outstanding.LoanDate:yyyy-MM-dd} 借給 {holder},還沒回來。");
    }

    var loan = new Loan(member.MemberId, book.Isbn, today, ReturnDate: null);
    _loans.Add(loan);
    return loan;
}

那三個throw就是把判斷搬到程式裡。以前店員要翻卡片盒才知道這本書在不在外面,翻到了才擋得住;現在程式先替兩個班別做同一個判斷,而且不會漏。

歸還那一段換了個做法:

// Loan 是不可變的 record,所以「歸還」是用一筆填好歸還日的新紀錄,換掉舊的那一筆。
var returned = outstanding with { ReturnDate = today };
var index = _loans.IndexOf(outstanding);
_loans[index] = returned;

紙卡上店員是在同一張卡上補寫歸還日期,程式這邊選了另一種做法——這個選擇到第二十四天談交易的時候會再回來。

啟動起來長這樣(2026-09-16跑的,第一行的日期取自系統時鐘,其餘四個數字來自種子資料,讀者跑起來會一樣):

=== 租書店櫃檯 v1 ===
今天 2026-09-16|書 100 本|會員 40 位|借閱紀錄 500 筆|未歸還 37 筆

1) 借出  2) 歸還  3) 查會員未歸還  4) 查書可不可借  5) 搜尋書名  0) 離開
> 會員編號:ISBN:✗ 《兩個人的偵探》已經在 2025-12-28 借給 李子涵,還沒回來。

選單的3和4是「查會員未歸還」和「查書可不可借」。昨天那盒卡片只能擇一,這裡兩種都有——因為資料在記憶體裡,同一份資料可以從兩個方向找。現在兩邊都還是從頭找到尾,昨天講的索引還沒進場。

代價寫在Program.cs第一行的註解裡:

// 第一版櫃檯程式:載入 S 規模種子資料到記憶體,然後進入選單迴圈。
// 程式關掉,這段時間借出、歸還的紀錄就全部消失——這是故意留下的問題。

關機就忘了。

明天

第四天把借還規則補成十條測試,順便回答一個問題:FindBook現在是_books.FirstOrDefault(b => b.Isbn == isbn),一百本書跑起來很快,那兩千本呢。

今天的程式碼會放在day03這個tag,取得出來、跑得起來:

https://github.com/EugeneSu0515/highflyer-ironman/tree/day03

git clone --branch day03 https://github.com/EugeneSu0515/highflyer-ironman.git
cd highflyer-ironman
dotnet test
dotnet run --project src/Ironman.Desk

這個系列是筆者正在寫的一套教學內容的濃縮版;完整版含每一次量測的原始輸出與更長的決策紀錄,三十天結束後會繼續往下走。

程式碼:https://github.com/EugeneSu0515/highflyer-ironman


上一篇
Day 2:三個名詞是抄下來的,不是設計出來的
下一篇
Day 4:十條測試,實作一行都沒改
系列文
AI寫得出程式,但它不知道你的店:一家租書店的30天技術選型實錄6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言